Skip to content

Ptech/sync upstream main - #2

Merged
henrique-marques-present merged 946 commits into
mainfrom
ptech/sync-upstream-main
Nov 3, 2026
Merged

henrique-marques-present merged 946 commits into
mainfrom
ptech/sync-upstream-main

Conversation

@henrique-marques-present

Copy link
Copy Markdown

Summary

  • Brings the fork's main up to date with upstream Bdaya-Dev/oidc main (fce87b98), 946 commits.
  • No changes of our own: the branch is exactly upstream main.

Why

itowin now pins upstream release tags from this fork (oidc-v4.0.0, oidc_default_store-v1.1.2, IT-6302 — upgrade oidc and flutter_secure_storage). Our only patch (oidc_default_store-v0.2.0+12.ptech.1, SharedPreferencesAsync) has been part of upstream since oidc_default_store 1.1.2, so the fork no longer needs to differ from upstream.

Merge

Use Create a merge commit, not squash or rebase, so upstream's commits keep their hashes and the fork shows 0 commits behind.

Not affected

  • Existing tags are unchanged: oidc-v0.9.0+1 and oidc_default_store-v0.2.0+12.ptech.1 stay for the releases that still pin them.
  • The old branch use-shared-preferences-async-on-default-store stays until no release uses it.

ahmednfwela and others added 30 commits July 13, 2026 18:54
…daya-Dev#301) (Bdaya-Dev#390)

The store's non-secure persistence now uses the modern
`SharedPreferencesAsync` API by default instead of the legacy synchronous
`SharedPreferences`. This lets callers share their own async preferences
instance with the store, as requested in Bdaya-Dev#301.

Mechanism:
- New `sharedPreferencesAsync` constructor parameter accepts (and injects,
  for tests) a `SharedPreferencesAsync` instance.
- The legacy `sharedPreferences` parameter is kept and still works end-to-end,
  but is now `@Deprecated` (pointing at the async replacement), following the
  repo's existing deprecation style.
- Backend selection is abstracted behind a small internal `_OidcPrefsBackend`
  interface with legacy and async adapters, so the rest of the store stays
  backend-agnostic; all non-secure reads/writes are now awaited.
- On the default path (no instance injected), `init()` runs a one-time,
  best-effort migration of only this store's own (`storagePrefix`-scoped) keys
  from the legacy store into `SharedPreferencesAsync`, guarded by a marker so
  it runs once and never overwrites existing async values. This preserves data
  on Android, where the legacy API (SharedPreferences) and
  `SharedPreferencesAsync` (DataStore Preferences) use different backing
  stores; on iOS/macOS/web/desktop both share one store so it is a no-op copy.

Dependency: bump `shared_preferences` constraint `^2.2.1` -> `^2.3.0` (the
first release shipping `SharedPreferencesAsync`). Adds a test-only
`shared_preferences_platform_interface` dev dependency for
`InMemorySharedPreferencesAsync` (in-memory async platform for VM tests).

Tests cover: default async round-trip, injected async instance, the
deprecated legacy-constructor path still working, scoped one-time
legacy->async migration, and migration not clobbering existing async values.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…aya-Dev#324) (Bdaya-Dev#391)

RFC 9449 §10 defines the `dpop_jkt` authorization-request parameter that
binds the issued authorization code to the DPoP proof key when Pushed
Authorization Requests are NOT used. The binding was previously emitted
only on the back-channel PAR request body, so a DPoP-enabled direct
(non-PAR) authorization request left the code unbound.

`loginAuthorizationCodeFlow` now resolves the PAR push decision up front
and, when DPoP is enabled with `bindAuthorizationCode` and the flow is
not pushing, threads the same key thumbprint (`OidcDPoPManager.thumbprint`
— the identical source the PAR path derives) into
`prepareAuthorizationCodeFlowRequest`. The thumbprint is set on the new
`OidcAuthorizeRequest.dpopJkt` member before any JWT-Secured Authorization
Request object is built, so it rides the front-channel URL and, when JAR
is used, inside the signed request object. The direct-request and PAR-body
binding sites are mutually exclusive: the code is never double-bound and
the PAR path is unchanged.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ant) (Bdaya-Dev#389)

* fix(oidc_core): honor expectedIssuer when validating id_token issuer (Bdaya-Dev#168)

Microsoft Entra ID multi-tenant (`common`/`organizations`) advertises a
non-substituted template issuer
(`https://login.microsoftonline.com/{tenantid}/v2.0`) that can never equal
the concrete per-tenant `iss` a real id_token carries, so `validateUser`'s
exact-match `iss` check (jose `claims.validate`, OIDC Core §3.1.3.7 step 2)
always failed with "Issuer does not match".

Introduce `resolveExpectedIssuer(metadata)` — mirroring the existing
`resolveAllowedIdTokenAlgorithms` convention — which returns
`settings.expectedIssuer ?? metadata.issuer`, and use it as the issuer
`validateUser` validates against. This lets a multi-tenant RP pin the concrete
tenant issuer via the existing `expectedIssuer` setting (added in the audit
hardening) instead of hacking the discovery document after `init()`.

Defaults are unchanged: with no `expectedIssuer` set the advertised
`metadata.issuer` is used exactly as before, so single-tenant behavior is
identical. Pinned by an Entra-shaped scenario (template metadata issuer +
concrete `iss`): passes with `expectedIssuer` set, and a regression pin asserts
it still fails without it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

* test(oidc_core): pin exact-match issuer rejection; resolve expectedIssuer on signed UserInfo — review follow-up

BLOCKER (security-negative test the reviewer mutation-tested for): in the
Bdaya-Dev#168 group, add a case where `expectedIssuer` pins concrete tenant A while
the id_token carries a DIFFERENT concrete tenant B (B != A, B != the template
metadata issuer). §3.1.3.7 requires an EXACT match, so pinning A MUST still
reject B — the pin is not "accept any non-template issuer". Asserts the
`validateUser` errors contain 'Issuer does not match'.

ADVISORY: the signed-UserInfo §5.3.4 `iss` check
(validateSignedResponseClaims / requireSignedResponseIssAud) compared the
verified UserInfo `iss` against the raw (possibly template) `metadata.issuer`.
The UserInfo `iss` MUST match the id_token `iss`, which for a multi-tenant OP
is the concrete per-tenant issuer, so resolve it through the same
`resolveExpectedIssuer(metadata)` pin used for the id_token `iss` check.
Adds two manager-level tests driving the full validate + UserInfo path: one
pinning that a concrete-tenant signed UserInfo is applied when `expectedIssuer`
pins it (fails if compared to the template — mutation-verified), and one
pinning that a WRONG concrete UserInfo `iss` is still rejected (never merged)
when `expectedIssuer` is set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…conds (Bdaya-Dev#369) (Bdaya-Dev#388)

The interactive login/logout loopback wait had no flow timeout, so the CLI
hung forever if the browser flow was abandoned (the user closes the tab, or
no browser opens), also leaking the bound loopback socket.

`CliUserManager.startListenerAndGetUri` now threads the caller's
`OidcPlatformSpecificOptions` and resolves a per-OS `flowTimeoutSeconds`
(windows/linux/macos), passing it to `listenForSingleResponse` — which
already force-closes the socket and throws a `TimeoutException` on expiry.
That exception is wrapped in an `OidcException` (message contains "timed
out") after a user-facing warning, mirroring `oidc_desktop`'s convention
exactly, including its default semantics: `null`/non-positive means an
unbounded wait, so existing behavior is unchanged unless the setting is
opted into. Both `getAuthorizationResponse` and `getEndSessionResponse` now
forward their `options` to the listener.

Pinned by a new `cli_user_manager_test` case that simulates an abandoned
flow (the listener never receives a redirect) and asserts the wait completes
with a timeout `OidcException` instead of hanging.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…; clamp the expiring-refire loop (Bdaya-Dev#392)

* feat(oidc_core): emit token refresh failure events; clamp expiring lead (Bdaya-Dev#120, Bdaya-Dev#123, Bdaya-Dev#154)

Adds OidcTokenRefreshFailedEvent to events(), emitted at all three refresh
failure sites (auto-expiry, manual refreshToken(), startup cached-load), so
apps can observe refresh failures without subscribing to userChanges() (which
is a value stream and cannot carry errors). The event classifies failures as
transient (network/5xx — liveness unknown, retryable, session kept) vs terminal
(invalid_grant per RFC 6749 §5.2 — refresh token dead, re-auth required) and
carries the parsed oauth error code, HTTP status, and whether a retry was
scheduled. The manual path keeps its throw (MSAL-style double-signal).

Bdaya-Dev#123: clamp the effective expiring-notification lead to min(refreshBefore,
half-life) and never fire 'expiring' synchronously during load(); a lead larger
than the token lifetime otherwise drove a tight infinite refresh loop.

Bdaya-Dev#154: emit the failure event before entering offline mode / tearing down timers,
and never forget the user on a transient failure. Offline mode entered due to a
failed refresh now reports OfflineModeReason.tokenRefreshFailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

* fix(oidc_core): dedupe startup refresh-failure event; stop resume forget-race (Bdaya-Dev#120, Bdaya-Dev#154)

BLOCKER 1 (Bdaya-Dev#120): the startup cached-load path emitted TWO
OidcTokenRefreshFailedEvents per failure (source=manual from _refreshToken,
then source=startupLoad from loadCachedTokens) and ran handleOfflineEligibleFailure
twice. _refreshToken now takes an OidcTokenRefreshSource (default manual);
loadCachedTokens passes startupLoad and no longer re-emits the event or re-runs
offline handling, so each startup refresh failure yields exactly one event with
the correct source and one offline-handling pass. Offline-absorbed failures still
return the cached user; terminal/offline-disabled failures still rethrow to the
outer catch that clears invalid tokens.

BLOCKER 2 (Bdaya-Dev#154): with the default supportOfflineAuth=false, handleTokenExpired
called forgetUser() unconditionally on expiry, which on resume (both timers
overdue) raced and beat the async refresh, clearing a user whose refresh token
was still valid. When the expired token still carries a refresh token, the forget
decision is now deferred to the refresh outcome via a single shared in-flight
future (_autoRefreshInFlight) that handleTokenExpiring and handleTokenExpired both
latch onto (exactly one refresh): SUCCESS keeps the replaced user; TERMINAL
failure forgets (the failure event precedes the null userChange); TRANSIENT
failure retains, matching the non-offline expiring-path retention. Tokens without
a refresh token keep today's immediate forgetUser.

Tests: t7 (single startupLoad event), t8/t9/t10 (resume-race success / terminal /
no-refresh-token); t6 relabeled to what it pins (the expiring-path retention); the
internals handleTokenExpired-forgets test now drives a terminal failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

* fix(oidc_core): make deferred auto-refresh close-safe after dispose()

The Bdaya-Dev#120 deferred-refresh machinery (handleTokenExpired -> _autoRefresh ->
_performAutoRefresh) let the async refresh outlive dispose(): when its
(possibly delayed) token response landed after the manager was closed it
called eventsController.add, throwing "Bad state: Cannot add new events
after calling close" (caught in CI on oidc_cli status_command_test 'warns
when the session token is already expired'). The success path was equally
unsafe — it would save/replace the user on a torn-down manager.

- Route every event emission through a new close-tolerant emitEvent() helper
  that no-ops when eventsController.isClosed, mirroring OidcValueStream.add's
  documented close-tolerance.
- Add an _isDisposed flag (set at the very start of dispose()) and gate the
  post-await outcome of _performAutoRefresh on it: after dispose the in-flight
  refresh is a COMPLETE no-op — no event, no createUserFromToken save/mutation,
  no offline handling, no retry timer. handleTokenExpired's terminal-forget
  continuation is likewise gated so it never forgets a disposed manager.
- dispose() latches the shared in-flight refresh future and swallows its
  settling with logger.finest so it can't surface an unhandled zone error.

Regression tests (oidc_core/test/post_dispose_refresh_test.dart) reproduce the
CLI scenario at library level for both a delayed-success and a delayed-terminal
-failure refresh; both fail against the pre-fix code (user replaced / exact
"Bad state" thrown) and pass with the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

* test(oidc): pump past the clamped expiring instant in no-id_token refresh test

The Bdaya-Dev#123 half-life clamp bounds the effective expiring lead to at most half
the token lifetime. With this test's 1s cached token and 700ms configured
refreshBefore, the lead clamps to 500ms, so the expiring event — and the
auto-refresh it drives — now fires ~500ms after load instead of at
1000 - 700 = 300ms. The 450ms observation window no longer captured the
single refresh, so refreshRequestCount was 0.

Widen the first window to 700ms (past the clamped 500ms instant, still before
the original 1000ms expiry). The test's intent is unchanged: exactly one
refresh, exactly one expiring event, no expired event, retained user with an
allowed expired id_token. This escaped because the oidc_core-only gate skipped
the dependent oidc package that owns this test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…a-Dev#116, Bdaya-Dev#227, Bdaya-Dev#256) (Bdaya-Dev#397)

The bundled `redirect.html` no longer hardcodes "Operation Successful". It now
posts the incoming redirect to the app as a structured envelope
`{"v":2,"type":"redirect","uri":...}` on the BroadcastChannel and waits for the
app to acknowledge with `{"v":2,"type":"ack","status":"ok"|"error"}` before
choosing a terminal state, fixing the misleading success message when the
app-side token exchange failed (Bdaya-Dev#116).

The page is now parse-first and state-driven: it reads `error`/`error_description`
/`error_uri` from both the query and the fragment (RFC 6749 §4.1.2.1) and renders
an error state directly; otherwise it shows success only on an `ok` ack, an
app-error on an `error` ack, and a neutral "you can now close this tab" fallback
when no ack arrives within ~10s or the browser refuses to close a script-unopened
(email-link) tab (Bdaya-Dev#227). Front-channel logout and same-page self-navigation keep
distinct states, and there's a graceful message when BroadcastChannel is missing.

The app consumer accepts both the v2 envelope and the legacy bare-URL string, so
an older copied page keeps working after an upgrade; `getAuthorizationResponse`
and `getEndSessionResponse` post the matching ack. Both example template copies
are updated in sync. Docs gain a re-copy/upgrade note and a troubleshooting entry
for a `redirect_uri` that never serves the page (e.g. a 502 at the proxy, Bdaya-Dev#256).


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…daya-Dev#124) (Bdaya-Dev#398)

Adds an opt-in OidcAppleNavigationMode.loopbackSystemBrowser to
OidcNativeOptionsApple. On macOS this runs the authorization / end-session
flow in the default system browser (url_launcher) and captures the redirect
on an RFC 8252 section 7.3 loopback listener (http://127.0.0.1:{port}),
reusing the oidc_loopback_listener transport and mirroring oidc_desktop's
ephemeral-port rewrite and flowTimeoutSeconds behavior. iOS ignores the mode
(ASWebAuthenticationSession remains the only iOS mechanism).

Also adds the loopback page-response sub-options (successfulPageResponse,
methodMismatchResponse, notFoundResponse) and a launchUrl test seam to
OidcNativeOptionsApple, and documents the mode, redirect-uri registration,
and App Sandbox entitlements in the oidc_darwin README.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ya-Dev#370) (Bdaya-Dev#401)

packages/oidc_web/lib/oidc_web.dart (the real OidcWeb plugin class)
imports oidc_web_core -> package:web (dart:js_interop), so it only
compiles for a browser target. CI's Flutter unit path is plain
`flutter test`, which runs on the VM ("tester") platform, so
test/library_surface_test.dart's @teston('chrome') suite was silently
skipped there (0 tests loaded, no failure) and oidc_web.dart never
appeared in any CI unit report.

`flutter test -d chrome` does not fix this: verified empirically that
-d/--device-id is ignored by `flutter test`, which still spawns the
native flutter_tester (VM) process regardless of the device given.
`--platform chrome` is the only invocation that actually threads a
browser Runtime through suite-load filtering (confirmed locally: the
suite reaches "loading ..." under --platform chrome, never under
-d chrome). The flag is marked deprecated/hidden ("intended for
testing the Flutter framework itself"), but there is no
non-deprecated alternative today.

Add melos script test:oidc_web:chrome (flutter test --platform
chrome, scoped to oidc_web) and a CI step running it on both main and
PR builds -- oidc_web has exactly one browser-tagged suite file today,
so a single invocation covers it, no per-file loop needed.

Also fix the test file's own header comment, which recommended the
broken `flutter test -d chrome` invocation.

Verified locally via `dart run melos run test:oidc_web:chrome` and
direct `flutter test --platform chrome` runs: the suite is reached and
loaded (proving the CI wiring gap is closed). Full local pass is
blocked by a long-standing, Windows-only flutter_tools bug unrelated
to project code (flutter/flutter#162798, #44583, #118778); this job
runs on ubuntu-latest, not the affected platform.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…-Dev#302) (Bdaya-Dev#403)

A UserInfo request rejected by the resource server with `401` (RFC 6750 §3)
while resuming an established session used to be swallowed: the cached
id_token still validated, so the user was saved without UserInfo and no
signal reached the app. A revoked access token paired with a live refresh
token is recoverable, and even an unrecoverable rejection should be
observable.

`validateAndSaveUser` gains `reactToUserInfoUnauthorized`, enabled only on
the cached-session resume path (never on initial login or a just-refreshed
token). On a UserInfo 401 it:

- parses the `WWW-Authenticate` challenge (RFC 6750 §3 error codes plus
  RFC 9470 step-up hints) via the existing OidcStepUpChallenge parser;
- attempts exactly one refresh-token grant when a refresh token exists,
  reusing the Bdaya-Dev#120 refresh machinery (which retries UserInfo once on success
  and emits OidcTokenRefreshFailedEvent on failure);
- emits a new typed OidcUserInfoFailedEvent on events() only when recovery is
  not possible or does not succeed.

No automatic forgetUser, consistent with the Bdaya-Dev#120 terminal-retention default:
the app listens for the event and decides logout vs. RFC 9470 step-up.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…arsing (Bdaya-Dev#400)

deleteClientConfiguration accepted any 2xx/3xx status as success (including
302 redirects) and threw a plain OidcException on failure, bypassing the
typed OidcErrorResponse parsing used by registerClient/readClientConfiguration
/updateClientConfiguration. Per RFC 7592 2.3, success is HTTP 204 (200
accepted leniently for servers that use it instead); every other status now
decodes the body and, when it carries an RFC 6749 error field, throws a typed
OidcException.serverError so 401/403/405 responses surface parsed error
fields to callers.

Fixes Bdaya-Dev#387.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…item 20) (Bdaya-Dev#402)

The PKCE code_verifier was persisted in the `state` namespace, which is
plaintext on every backend (localStorage on web, SharedPreferences on
mobile/desktop). Anyone who scrapes that store together with the
authorization code can complete the token exchange (RFC 7636 §1).

Persist it in the `secureTokens` namespace instead — encrypted at rest on
web via OidcWebStore and secure-storage-backed on mobile/desktop via
OidcDefaultStore — keyed by the state id, the same posture the nonce already
gets. It is stripped from the state payload before that payload is written.

The redirect reader prefers the secureTokens value and falls back to the
(now legacy) in-payload value for one release, so a login already in flight
when the app upgrades still completes. The entry is dropped when the flow
completes and when a stale state is cleared, so the secret never outlives
the short-lived flow.

New store helpers setStateCodeVerifier/getStateCodeVerifier own the
namespaced key. Tests pin the write location, the round-trip through the new
location, the legacy-payload fallback, cleanup, and that the web store
persists it as ciphertext.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…cache, loaded-token validity controls (Bdaya-Dev#399)

* feat(oidc_core): cache-first init default + discovery TTL + loaded-token validity controls (Bdaya-Dev#201, Bdaya-Dev#205)

Rebased onto origin/main (1.0.0 + Bdaya-Dev#388-Bdaya-Dev#392). Reconciled with Bdaya-Dev#392's
restructure of loadCachedTokens/init: the startup refresh now delegates
failure signalling and offline handling to _refreshToken(source:
startupLoad) — a single OidcTokenRefreshFailedEvent instead of the
branch's prior outer try/catch + handleOfflineEligibleFailure double
signal — while preserving this branch's Bdaya-Dev#205 isLoadedTokenAcceptable /
shouldRemoveInvalidToken controls and cache-first init/TTL. The refactored
attachLifecycleListeners() routes native browser events through Bdaya-Dev#392's
close-safe emitEvent rather than eventsController.add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

* fix(oidc_core): single-refresh cache-first cold start; retract reject-but-keep user (Bdaya-Dev#201, Bdaya-Dev#205)

BLOCKER 1: on a cacheFirst cold start with an expired-but-refreshable
cached token, the restored expired user armed the expiry timers (path A:
_autoRefresh, latched) while the background revalidation also refreshed
(path B: loadCachedTokens -> _refreshToken source=startupLoad, which
bypassed the latch). Two concurrent /token exchanges could trip an OP's
RFC 9700 refresh-rotation reuse detection and revoke the token family on
an ordinary reopen. Suppress the expiry-driven auto-refresh while the
background pass owns the first refresh (new _cacheFirstRevalidationInFlight
gate), so the DEFAULT mode performs exactly ONE /token call and emits
exactly ONE failure event (source=startupLoad). Undoes the prior masking
that pinned t7 to blockingValidate; adds t7b under the default mode plus
success/terminal-failure cold-start tests.

BLOCKER 2: the background reconciliation only retracted the surfaced user
when the on-disk token had been removed, so a policy-REJECTED token kept
on disk (shouldRemoveInvalidToken=false or supportOfflineAuth=true) left
the rejected user logged in — diverging from blockingValidate. Decouple
the in-memory retraction from disk removal: whenever loadCachedTokens did
not replace the surfaced user (currentUser still identical to the restored
object), retract it in-memory without clearing the kept-on-disk tokens.
Re-run the Bdaya-Dev#205 validity-callback matrix under BOTH modes.

SHOULD-FIX 3/4/5: qualify the blockingValidate dartdoc/migration note
(exact pre-1.0 network behavior also needs discoveryDocumentMaxAge:
Duration.zero; TTL default unchanged); document the intentional cold-start
userChanges double-emission (dedup is unsafe — it would suppress the
verified-user surface); add a test pinning that the persisted discovery
copy excludes in-memory metadataSeed values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add OAuth 2.0 Dynamic Client Registration integration pieces on top of
the existing RFC 7591/7592 endpoints:

- OidcClientAuthentication.fromRegistrationResponse: derive a ready-to-use
  client authentication from a registration response, mapping
  token_endpoint_auth_method (+ credential presence) to none /
  client_secret_basic / client_secret_post / client_secret_jwt (generated),
  defaulting to client_secret_basic per RFC 7591 §2, and throwing
  OidcException for private_key_jwt (key is client-held, never in the
  response) and unknown/unsupported methods.
- Typed OpenID Connect Dynamic Client Registration 1.0 §2 metadata fields
  on OidcClientRegistrationRequest: default_max_age, require_auth_time,
  default_acr_values, initiate_login_uri, request_uris (regenerated .g.dart).
  application_type / subject_type / id_token_signed_response_alg /
  sector_identifier_uri were already present.
- OidcClientRegistrationResponse.toUpdateRequest: build the full-metadata
  RFC 7592 §2.2 PUT body, dropping server-managed fields and documenting
  client_secret / registration_access_token rotation on the update response.

Coordination: the tls_client_auth_* subject params are added by the sibling
mTLS lane on request.dart and are intentionally not added here to avoid a
conflict. Deferred Bdaya-Dev#385 follow-ups (not in this PR): register ->
OidcUserManager.fromRegistration bootstrap and a dedicated
registration-credential store namespace.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…daya-Dev#412)

Add the platform-agnostic (dart:io-free) core surface for mutual-TLS
client authentication (RFC 8705), consumable by oidc_web:

- OidcClientAuthentication.tlsClientAuth / .selfSignedTlsClientAuth
  named ctors (§2.1 / §2.2): header-less, emit only client_id in the
  body.
- OidcConstants_ClientAuthenticationMethods.tlsClientAuth +
  selfSignedTlsClientAuth.
- OidcMtlsEndpointAliases typed view over mtls_endpoint_aliases (§5) plus
  OidcProviderMetadata.mtlsEndpointAliases,
  tlsClientCertificateBoundAccessTokens[OrDefault] (§3.3), and a single
  resolveEndpoint(name, {useMtlsAliases}) choke point applying the
  alias-or-fallback rule (§5).
- OidcUserManagerSettings.useMtlsEndpointAliases (default false, explicit
  opt-in, not auto-inferred from the auth method).
- OidcClientRegistrationRequest: tls_client_auth_subject_dn / _san_dns /
  _san_uri / _san_ip / _san_email (§2.1.2) +
  tls_client_certificate_bound_access_tokens (§3.4).

Non-goals (deferred to phase 2, native layer): cert-bearing http.Client /
dart:io SecurityContext transport; cnf x5t#S256 validation; web
throw-on-mTLS. The manager call sites are intentionally NOT rewired onto
resolveEndpoint in this PR (that touches ~20 read sites); the helper is
the introduced choke point ready for phase-2 wiring.


Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(release): publish packages

 - oidc@2.0.0
 - oidc_cli@1.0.1
 - oidc_core@2.0.0
 - oidc_darwin@1.1.0
 - oidc_default_store@1.1.0
 - oidc_web_core@1.1.0
 - oidc_android@1.0.1
 - oidc_desktop@1.0.1
 - oidc_linux@1.0.1
 - oidc_platform_interface@1.0.1
 - oidc_web@1.0.1
 - oidc_windows@1.0.1

* chore(release): regenerate oidc_cli version.dart for 1.0.1

The melos release flow bumps pubspec.yaml but does not run build_runner, so
the build_version-generated version.dart lagged the 1.0.1 bump. Regenerated
so ensure_build passes and the CLI reports its true version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: ahmednfwela <ahmednfwela@bdaya-dev.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ixes Bdaya-Dev#416) (Bdaya-Dev#417)

* build(melos): regenerate build_version files during `melos version` (fixes Bdaya-Dev#416)

`melos version` bumps each package's pubspec.yaml but never runs
build_runner, so oidc_cli's build_version-generated lib/src/version.dart
lags behind the released version. Add a version-command `preCommit` hook
that runs `melos run generate:dart` and then explicitly
`git add packages/oidc_cli/lib/src/version.dart`.

The explicit `git add` is required: melos's `_gitStageChanges` only
stages the aggregate changelogs and the pubspec.yaml / CHANGELOG.md it
edits itself, never files a preCommit hook regenerates. Without the
add, the freshly regenerated version.dart is left unstaged and every
release commit ships a stale version, so the hook would be inert.

The hook is a single line joined with `&&` rather than a multi-line `|`
block on purpose: melos runs hooks through `sh -c 'eval "$MELOS_SCRIPT"'`
on POSIX but `cmd.exe /C %MELOS_SCRIPT%` on Windows, and cmd truncates
the command at the first embedded newline -- a `|` block would silently
run only generate:dart and drop the git add on Windows. Verified
end-to-end against melos 7.8.2: a scratch `melos version -V
oidc_cli:1.0.2 --no-git-commit-version` run fires the hook and stages
version.dart with the bumped value (git diff --cached shows the change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

* docs(melos): correct the hook comment's melos version reference (7.8.2 -> 7.8.0)

The resolved version in pubspec.lock is melos 7.8.0; the comment misstated it.
Mechanics are unchanged and identical across those versions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…re path (Bdaya-Dev#418) (Bdaya-Dev#419)

* fix(oidc_android)!: restore the redirect receiver as a fallback capture path

Auth Tab is not a universal capture path. AuthTabIntent performs no capability
detection: launch() only stuffs extras onto an Intent, and Builder.build()
deliberately carries a null EXTRA_SESSION "so that this is interpreted as a
Custom Tab intent by browser implementations that don't support Auth Tab".
That fallback preserves the UI, not the redirect capture -- a plain Custom Tab
does not intercept the redirect_uri, it merely navigates to it.

Since 05bf018 removed OidcRedirectActivity and its intent-filter, that
navigation resolved to no app on every browser except Chrome 137+, leaving the
user on a blank tab. See Bdaya-Dev#418.

Redirect capture is dual-path again: Auth Tab stays preferred where supported
(its result survives process death), and OidcRedirectActivity catches the
redirect everywhere else. Whichever fires first wins; finishPending() resolves
the Dart callback exactly once. A plain FlutterActivity host works again
instead of failing with NO_COMPONENT_ACTIVITY.

BREAKING CHANGE: apps must set the oidcRedirectScheme manifest placeholder,
as the README and getting-started docs have always instructed. 2.0.0 silently
made it a no-op. The library ships NO default value on purpose: a placeholder
declared in a library module is substituted into the library manifest before
the app merge and would shadow the app's own scheme (verified by building the
example -- a default of 'com.bdayadev.oidc.unset' produced exactly that in the
merged manifest). An app that omits it now fails the manifest merge naming the
placeholder, instead of failing silently at runtime.

Also fixed:
- minSdkVersion 21 -> 23; androidx.browser:1.10.0 declares 23, so the old
  value was a manifest-merge failure waiting to happen in consuming apps.
- captureMode now emits the customTabsRedirectActivity value that
  oidc_platform_interface's parser actually recognises; anything else decoded
  silently to OidcRedirectCaptureMode.unknown.

Test gaps that let this ship, all closed:
- shared_e2e.dart called loginAuthorizationCodeFlow() but never asserted the
  result -- a null only logged a warning and exceptions were caught and logged,
  so the Android E2E reported green whether or not a redirect was captured.
- oidc_android had no native tests at all (android/src/ held only main/).
  Adds a Robolectric suite covering intent-filter capture, scheme/host
  matching, once-only resolution, flow supersession, and a cross-language
  guard that the emitted captureMode strings match the Dart enum.
- The CI emulator used the default AOSP image, which ships no browser, so the
  browser leg could never run. Switches to google_apis_playstore and wires the
  native suite into the workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* fix(ci): generate the gradle wrapper before the native Android tests

gradlew and gradle-wrapper.jar are gitignored repo-wide (only
gradle-wrapper.properties is tracked), so a fresh CI checkout has no wrapper
and the new native test step died with `./gradlew: No such file or directory`
(exit 127). `flutter build apk --config-only` generates the android build
files without building an artifact; verified locally by deleting both files
and confirming --config-only recreates them.

Also makes a conformance login failure legible. The harness truncates a raw
exception to "1 test(s) failed", and the Dart logger is captured into the
certification archive rather than CI stdout, so neither the module name nor
the reason reached the job output. A throw is now reported via fail() naming
the module, and the null/non-null outcome is print()ed so it survives to
stdout. This does not weaken the assertion added for Bdaya-Dev#418 -- it only makes the
failure diagnosable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* fix(ci): format shared_e2e.dart and make the linux patrol failure legible

The Format verify step failed on the previous commit -- `dart format` wraps the
new diagnostic print(). Reformatted.

Adds --verbose to the linux patrol invocation (the ios job already runs
verbose). patrol forwards no test stdout to the job log by default: the
conformance failure reports only "1 test(s) failed" with the reason, the
module name, and even plain print() output all dropped, which makes the
previous commit's diagnostics invisible. This is diagnostic only -- no test
behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* fix(test): assert the aggregate login count, not every module

The previous per-module `expect(authResult, isNotNull)` was wrong. The
oidcc-client-basic-certification-test-plan mixes POSITIVE modules with
NEGATIVE ones -- oidcc-client-test-invalid-iss and friends, where the OP
deliberately returns a broken response and the client is REQUIRED to reject
it. A null result there is the correct outcome, so requiring every module to
produce a user fails the suite on conformant behaviour.

The --verbose run showed exactly this:

    [e2e] oidcc-client-test             -> authResult ok
    [e2e] oidcc-client-test-invalid-iss -> authResult NULL   <- assertion fired

Login does complete; the assertion was simply invalid for module 2.

Replaced with an aggregate: at least one module must complete a login. That
keeps the Bdaya-Dev#418 regression signal intact -- a platform that cannot capture the
browser redirect scores zero, which is the whole failure mode -- without
encoding which of the 14 modules are negative, so it stays correct if the plan
changes. Per-module outcomes are still printed for diagnosis, and an exception
is recorded rather than fatal since negative modules may legitimately throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* fix(ci): give the Android example an androidTest source set so tests actually run

The Android integration job has executed ZERO tests for its entire life while
reporting success. On main's last green run it printed:

    Test summary:  Total: 0  Successful: 0  Failed: 0  Skipped: 0  Duration: 7s

with no "Discovered N test(s)", no "Results:", no PATROL_LOG anywhere in the
136KB log -- while the linux log contains all three. This is why the suite
could not catch Bdaya-Dev#418: the one job that should have caught an Android redirect
regression had never run a test.

Root cause: packages/oidc/example/android/app/src contained only debug, main
and profile -- no androidTest source set at all -- and build.gradle declared
the stock androidx.test.runner.AndroidJUnitRunner. So connectedDebugAndroidTest
enumerated zero JUnit classes, patrol's Dart-side listDartTests() RPC was never
reached, and the run finished in 7s. iOS is the control case: it HAS the
analogue (ios/RunnerUITests/RunnerUITests.m) and correctly reports 3-4 tests.
patrol's CLI does not generate this file; it only mentions it in a comment.

Adds:
- androidTest/java/com/bdayadev/oidc/example/MainActivityTest.java, the
  Parameterized JUnit entrypoint that calls listDartTests(). Passes
  FlutterFragmentActivity because the example declares no custom MainActivity
  (the manifest launches the Flutter activity directly); it is only the
  fallback path anyway, since PatrolJUnitRunner.setUp prefers
  getLaunchIntentForPackage and the manifest does declare a LAUNCHER filter.
- testInstrumentationRunner -> pl.leancode.patrol.PatrolJUnitRunner (the stock
  runner would throw ClassCastException in MainActivityTest), plus
  clearPackageData, testOptions execution ANDROIDX_TEST_ORCHESTRATOR (the
  runner reads the orchestrator's listTestsForOrchestrator argument to detect
  the discovery pass -- without it the two-phase protocol never engages), and
  androidTestUtil androidx.test:orchestrator:1.5.1 (never 1.5.0: it cannot
  handle whitespace in test names, and ours contain spaces).

Proven locally without an emulator:
  ./gradlew :app:assembleDebugAndroidTest -> BUILD SUCCESSFUL in 29s
  build/app/intermediates/.../com/bdayadev/oidc/example/MainActivityTest.class
  build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk

Also in this commit:
- A CI ratchet. patrol_cli_plus exits 0 on an empty run (its android backend
  reads only gradle's exit code and prints the "Total: 0" summary without
  inspecting it), and fixing that upstream needs a publish. The job now greps
  patrol's own Total: line and fails below 4, so a green Android job that ran
  nothing is impossible without waiting on a patrol release. Green is not the
  signal; "Total: N" is.
- --verbose on android and windows, matching ios/web/linux. patrol forwards no
  test stdout without it, which is why the Total line and the [e2e] counts were
  invisible in the first place.
- pubspec key patrol: -> patrol_plus:. The fork's reader does
  yaml['patrol_plus'] and SILENTLY returns the default config when absent, so
  the whole block (app_name, package_name, bundle_ids) was being discarded and
  the uninstall finalizers skipped. Upstream patrol's docs still show the old
  key, which is where this came from.
- Emulator target google_apis_playstore -> google_apis. The Play Store image
  never booted: 497 "adb: device unauthorized" lines and the AVD step timing
  out at 900s, where main's default image produced zero. Play Store images ship
  a locked-down adb that will not accept the runner's key under this job's
  relocated ANDROID_SDK_HOME.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* ci(android): probe emulator browser + custom-scheme resolution

The first run with the discovery fix executed 4 tests (up from 0, the whole
point) but completed 0 of 14 conformance logins. "No browser on the image"
and "browser present but the redirect is not captured" are different defects
with different fixes, and the audit flagged the google_apis browser contents as
UNVERIFIED, so this settles it rather than guessing.

Three probes before the patrol run:
- pm list packages, filtered to chrome/browser/webview/firefox
- what resolves an https VIEW+BROWSABLE intent (is there a browser at all)
- what resolves com.bdayadev.oidc.example://oauth2redirect -- this one directly
  tests the Bdaya-Dev#418 fix in the INSTALLED app: it should resolve to
  com.bdayadev.oidc.OidcRedirectActivity. If it does, the manifest wiring is
  correct on-device and the gap is browser-side; if it resolves to nothing, the
  wiring is the problem.

Diagnostic only; the ratchet and the test run are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* ci(android): probe the redirect scheme AFTER install, not before

The previous probe ran before patrol_plus test, which is what builds and
installs the APK -- so 'com.bdayadev.oidc.example://oauth2redirect -> No
activity found' was guaranteed regardless of whether the manifest is wired.
Mis-ordered on my part; the result proved nothing.

The pre-install probes DID settle one thing: google_apis ships Chrome
(package:com.android.chrome) and an https VIEW intent resolves to
com.android.chrome/...IntentDispatcher. So the emulator has a real browser and
'no browser' is eliminated as the cause of 0/14 logins.

Moves the custom-scheme probe after the patrol run and adds two more: is the
app installed, and what VIEW/BROWSABLE filters does it actually declare. That
is the direct on-device test of the Bdaya-Dev#418 fix -- if the scheme resolves to
OidcRedirectActivity the wiring is correct and the remaining failure is
browser/flow-side; if it does not, the wiring is the defect.

patrol's exit status is captured via PIPESTATUS and deferred to the end of the
script rather than aborting under set -e, because the diagnostics matter most
when the run failed. The ratchet remains an additional gate: a run can execute
4 tests and still fail one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* ci(android): read the redirect scheme from the built APK, not the device

Two probe placements were wrong before this one, so state the reasoning.

An adb probe AFTER the run reports 'app NOT installed': patrol uninstalls the
app when it finishes, and its uninstall finalizers only began running once the
pubspec key was corrected to patrol_plus: -- fixing that key is what enabled
them. A probe BEFORE the run is equally useless because patrol is what installs
the app in the first place.

A local APK is not a substitute either. Dumping the local app-debug.apk showed
android:scheme="com.bdayadev.oidc.unset" and I nearly reported the wiring as
broken; the artifact was 22 seconds OLDER than the merged manifest beside it and
predated the library-default removal. The current merged manifest correctly says
com.bdayadev.oidc.example, and the only surviving 'unset' strings in the tree are
comments.

So: aapt2 dump xmltree over the APK patrol actually built. Install-independent,
immune to the uninstall finalizer, and it reads the bytes that were installed
rather than anything reconstructed. If OidcRedirectActivity is registered for
com.bdayadev.oidc.example there, the Bdaya-Dev#418 wiring is correct in CI and the
remaining 0/14 is browser/flow-side.

Diagnostic only. The ratchet and the deferred PATROL_RC are unchanged --
confirmed working on the last run: 'Android patrol executed 4 test(s).' passed
the floor and the job still failed on patrol's own exit 1, no masking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* ci(android): suppress Chrome first-run before the conformance leg

Hypothesis, stated as one. Every other precondition is now individually proven:

- Chrome IS on the image: package:com.android.chrome, and an https VIEW intent
  resolves to com.android.chrome/...IntentDispatcher (pre-install probe).
- The APK registers the receiver correctly: aapt2 dump xmltree of the APK patrol
  actually built shows OidcRedirectActivity, exported, VIEW+DEFAULT+BROWSABLE,
  android:scheme="com.bdayadev.oidc.example" -- the app's own scheme, so the
  Bdaya-Dev#418 wiring is right in CI and the earlier local dump showing 'unset' was a
  stale artifact 22s older than its own merged manifest.
- The OP needs no human: linux and macos complete 6 of 14 modules unattended
  over a loopback redirect.

Android still completes 0/14, so the remaining suspect is Chrome never
NAVIGATING at all: a first-run device shows the welcome/ToS gate, and a Custom
Tab opened behind it never reaches the auth URL, never redirects, and every
module times out -- which matches the observed ~34s per module across 473s.

/data/local/tmp, not /data/local: the older path is SELinux-blocked. The first
token is a placeholder because Chrome parses the file as an argv line and
discards element 0. The file is echoed back so the run records whether it was
actually written.

If 0/14 survives this, the first-run gate is eliminated too and the next suspect
is the Custom Tab -> intent-filter handoff itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* fix(ci): fail when an integration coverage file has zero source records

The cheapest possible signal for "this platform ran nothing" was sitting in the
artifacts the whole time and nobody looked at it.

On main's LAST GREEN RUN (29303400030) the android job uploaded
android-coverage.info at exactly 0 bytes -- 0 SF records, 0 covered lines --
because it executed zero tests. It was uploaded, downloaded, combined and
shipped to Codecov without a single objection.

Three layers each declined to notice:
  - `if-no-files-found: error` on the upload asserts the file EXISTS. A 0-byte
    file exists.
  - codecov.yml sets `patch: false` and `project: false`, so every coverage
    status is disabled and Codecov gates nothing.
  - the repo's own "[Coverage] Assert Flutter package coverage survived" guard
    -- written for exactly this class of bug, when a truncation silently dropped
    Flutter unit coverage -- covers only unit_tests' four packages. It was never
    extended to the six integration coverages.

So the guard pattern already existed and simply had not been pointed at the
jobs that needed it. This points it there: before the combine, every
*-coverage.info must carry at least one SF record, and the platform is named in
the error. An empty lcov is not a coverage regression to debate, it is proof the
platform executed nothing.

Verified against both real artifacts:
  main run 29303400030 (0 tests) -> 0 bytes, 0 SF   -> guard FIRES
  run  30140407957 (4 tests)     -> 72630 bytes, 132 SF, 1006 covered lines,
                                    including packages/oidc_android/lib/oidc_android.dart
                                                   -> guard passes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* chore: keep session-artifacts out of the repo

git add -A swept a session handoff page into the previous commit. It is a local
deliverable for this session, not repo content, so it is removed from tracking
and the directory is ignored. The file itself stays on disk.

* docs: rewrite comments as maintenance context, not incident history

The comments added across this change were written as a narrative of how the
bug was found: dates, run ids, "this is how Bdaya-Dev#418 shipped", "verified live",
first-person asides. That is session history, not information a maintainer
reading this code in a year needs, and it violates the repo's own rule that a
comment explains why and does not narrate.

Rewritten to state the constraint and the reason it exists, dropping the
storytelling, the em-dashes, and the issue-number references that only make
sense with this session's context. The technical content is unchanged: why the
capture path is dual, why the library ships no placeholder default, why the
orchestrator is required, why the coverage guard exists.

Comments only, no behaviour change. oidc_android native suite still 13/13;
workflow yaml re-parsed and the ratchet, deferred exit status and coverage
guard all confirmed intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

* fix(example): stop printing bearer tokens into the conformance logs

The root logger listener prints every record to stdout and the per-module
listener archives them, so `token.toJson()` put the access, id and refresh
tokens into a public CI log. Log the token's shape instead — which is what
the surrounding assertion actually cares about.

Also corrects two docs claims: Auth Tab's result path needs a
ComponentActivity host, so the default FlutterActivity always goes through
OidcRedirectActivity regardless of browser version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…daya-Dev#421, Bdaya-Dev#422, Bdaya-Dev#423, Bdaya-Dev#424) (Bdaya-Dev#425)

* feat(oidc_core): silent-acquisition API at the manager/app boundary (Bdaya-Dev#421, Bdaya-Dev#422, Bdaya-Dev#423, Bdaya-Dev#424)

Four additive, non-breaking additions to `OidcUserManagerBase`, all of which
land on the same file and are therefore shipped together.

Bdaya-Dev#424 — typed failure classification
  * `OidcException.kind` (nullable `OidcTokenRefreshFailureKind`) surfaces on
    the THROWING path the classification the library already computed for
    `OidcTokenRefreshFailedEvent`.
  * `OidcInteractionRequiredException extends OidcException` marks exactly the
    failures whose recommended handling is "start an interactive login", so
    callers stop string-matching `invalid_grant`. Being a subclass, every
    existing `on OidcException` catch keeps matching.
  * `OidcException.raw` is the generative constructor a subclass needs to carry
    an `errorResponse` AND the internal error fields at once (the two existing
    constructors each force one of them to null).

Bdaya-Dev#421 — `getAccessToken({minValidity, forceRefresh})`
  Returns a token guaranteed valid for at least `minValidity`, refreshing only
  when it would not be. Concurrent callers join the existing in-flight refresh
  latch (previously shared only by the two automatic on-expiry handlers), so N
  parallel API calls perform ONE token exchange — the rotating-refresh-token
  stampede: rotation invalidates the previous refresh token on every exchange
  (RFC 9700 §2.2.2 requires rotation or sender-constraining for public
  clients), so a second concurrent exchange presents an invalidated token and
  the AS "will revoke the active refresh token" (§4.14.2).

  The legacy `refreshToken()` is deliberately UNCHANGED: it stays
  unconditional and un-coalesced for callers that need a raw exchange (e.g.
  with an `overrideRefreshToken`, which must never be coalesced with a refresh
  of a different token).

Bdaya-Dev#422 — `createDPoPProof` / `cacheDPoPNonce` / `dpopThumbprint`
  A thin public facade over the already-existing `OidcDPoPManager`
  primitives, which stays `@protected` so its internals remain encapsulated.
  Without this, enabling `settings.dpop` sender-constrained the token but left
  the app unable to prove possession on its own API calls.

Bdaya-Dev#423 — `signInSilent({timeout, scopeOverride, extraParameters})`
  Promotes the `prompt=none` path to public API: refresh_token grant first
  when one is available (through the same latch), otherwise a hidden-iframe
  `prompt=none` request. The forced iframe options preserve every other
  configured platform option. `reAuthorizeUser()` is kept and deprecated.

Shared plumbing: `_performAutoRefresh` now takes the refresh `source` (so a
caller-initiated refresh is reported as `manual`, not `autoExpiry`) and
returns the original error/stackTrace alongside the classification, so a
caller that must THROW can preserve the diagnostics the automatic paths
deliberately swallow.

33 new unit tests in `test/app_boundary_api_test.dart`.

* docs(oidc): lead the access-token guide with getAccessToken, DPoP and signInSilent

The guide previously showed `currentUser?.token.accessToken` — a
freshness-unaware read — in both the dio interceptor and the http client
example, and had no DPoP path at all despite the package implementing RFC 9449
end-to-end.

* leads with `await userManager.getAccessToken()` and the
  `on OidcInteractionRequiredException` handling pattern, with a table of what
  each situation returns/throws;
* documents `signInSilent()` and states the platform matrix honestly
  (`prompt=none` in a hidden iframe is web-only, and even there depends on a
  third-party-context cookie that Safari's ITP blocks);
* adds the DPoP-protected-resource recipe (`DPoP` + `Authorization: DPoP`
  header pair, plus the `use_dpop_nonce` retry);
* updates the dio/http snippets to await the helper.

* fix(oidc_core): close cache-first revalidation race in getAccessToken/signInSilent

Review round on Bdaya-Dev#425: getAccessToken()/signInSilent() joined the shared
_autoRefreshInFlight latch but not the separate in-flight
OidcInitMode.cacheFirst (default) background revalidation, which refreshes
via the raw, un-coalesced _refreshToken. Calling either immediately after
init() returns (cacheFirst returns before the background pass settles, by
design) raced that revalidation and presented the same refresh token in a
second concurrent /token exchange (RFC 9700 rotation-reuse hazard).

- Add _cacheFirstRevalidationFuture + _joinCacheFirstRevalidationIfInFlight
  so both entry points await/join the in-flight revalidation and reuse its
  outcome instead of starting a competing exchange.
- Fix _performAutoRefresh returning failureKind: transient even when offline
  mode had already absorbed the failure, which made getAccessToken/
  signInSilent throw instead of returning the cached session while offline.
- Remove a now-redundant dpopThumbprint override in login_dpop_flow_test.dart
  that silently shadowed Bdaya-Dev#422's new base-class getter (annotate_overrides).
- Add regression tests for both fixes, mutation-checked against a revert.

packages/oidc_core: 731 tests passing (724 before this round).

* fix(oidc_core): keep the cache-first revalidation gate self-clearing when init() itself fails

Advisory A1 (PR Bdaya-Dev#425 review): `_scheduleBackgroundRevalidation` awaited
`initFuture` OUTSIDE its own try/finally. `init()`'s cache-first branch
restores the cached user, arms `_cacheFirstRevalidationInFlight` /
`_cacheFirstRevalidationFuture`, THEN calls the overridable
`attachLifecycleListeners()` — if that throws synchronously, `initFuture`
completes with an error, the unguarded await rethrows it before the
try/finally, and the finally that clears both fields never runs.

Every later getAccessToken()/signInSilent() call then re-joins the same
permanently-errored future via `_joinCacheFirstRevalidationIfInFlight` and
rethrows the stale init error forever (`ensureInit()` doesn't gate this:
`AsyncMemoizer.hasRun` flips true synchronously on `init()` call, independent
of success/failure). Worse, `_cacheFirstRevalidationInFlight` also never
clears, permanently suppressing the on-expiry auto-refresh gated on it in
handleTokenExpiring/handleTokenExpired.

Fix: move `await initFuture` inside the try, so the catch-all/finally always
runs regardless of whether init() succeeded — matching the existing
"revalidation future never throws" guarantee the dartdoc already claimed
(now made durably true, and the doc expanded to say why).

Regression test: a manager whose attachLifecycleListeners() throws
synchronously, seeded with a cache-first-restorable token; asserts
getAccessToken() after the failed init() returns the cached token instead of
leaking the init error. Mutation-checked (fails without the fix, verbatim
_InjectedAttachFailure rethrow; passes with it).

* test(oidc_core): close advisory A3 — pin the auto-refresh-disabled consequence of the A1 fix

The A1 fix (d9d246c) moved `await initFuture` inside the try in
`_scheduleBackgroundRevalidation` so its finally always clears
`_cacheFirstRevalidationInFlight`/`_cacheFirstRevalidationFuture`. The
shipped regression test only asserted the visible symptom — a later
getAccessToken() no longer rethrowing the stale init() error — but that
call masks the more severe, silent consequence: `_cacheFirstRevalidationInFlight`
also gates handleTokenExpiring/handleTokenExpired directly (an early
`if (_cacheFirstRevalidationInFlight) return;` in each, ahead of
_autoRefresh), so a skipped finally leaves on-expiry auto-refresh
permanently and silently disabled.

Add a probe that never calls getAccessToken()/signInSilent(): reuses the
existing _ThrowingAttachManager harness (attachLifecycleListeners() throws
synchronously, reproducing init() failing after the cache-first gate is
armed), waits for the already-scheduled background revalidation to settle,
then drives handleTokenExpiring and handleTokenExpired directly off the
same token event (back-to-back, sharing the _autoRefresh in-flight latch)
and counts real /token exchanges via the mock client.

Mutation-checked: reverting the A1 fix (await initFuture back outside the
try) makes the new assertion fail with the predicted 0-exchange symptom
(Expected: <1> Actual: <0>) — both handlers hit their early return because
the gate never clears. Restoring the fix returns it to green.

* style(oidc_core): dart format the A3 regression test

The `handleTokenExpiredForTest` wrapper was split across two lines; the
stable formatter wants it on one. The Format step gates `unit_tests
(stable)`, so this failed the job and SKIPPED every subsequent test step
(Analyze, VM/Chrome/Firefox suites, Flutter packages, coverage asserts) —
the stable matrix produced no signal at all.

Formatting only; no behaviour change. `dart format --set-exit-if-changed`
now clean, and the file's 2 tests still pass.

---------

Co-authored-by: Ahmed Fwela <claude1@digrum.com>
- oidc@3.0.0
 - oidc_android@2.0.0
 - oidc_core@2.1.0
 - oidc_cli@1.0.2
 - oidc_default_store@1.1.1
 - oidc_desktop@1.0.2
 - oidc_darwin@1.1.1
 - oidc_linux@1.0.2
 - oidc_platform_interface@1.0.2
 - oidc_web@1.0.2
 - oidc_web_core@1.1.1
 - oidc_windows@1.0.2

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ting 21 (Bdaya-Dev#427)

The getting-started guide told the reader to set `minSdkVersion 21`. Since
oidc_android 2.0.0 that instruction fails the build: the plugin depends on
androidx.browser:browser:1.10.0, which declares minSdk 23, so a lower value in
the consuming app fails the manifest merge. Neither doc stated the requirement
anywhere, so someone hitting that failure had nothing to explain it - and the
only copy-pasteable snippet in the guide was the one that caused it.

Both docs now name the floor, cite the dependency that sets it, and flag it as
the one change that can break a build when upgrading from 1.0.1 (floor 21).

The flutter_secure_storage step also asked for its own floor, which the 23
above already satisfies; it now says so rather than offering a second,
conflicting number.

The platform summary still described Android as "Chrome Custom Tabs", which the
next section of the same file contradicts - capture has been dual-path since
2.0.0, Auth Tab with a Custom Tabs fallback.


Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- oidc_android@2.0.1
 - oidc@3.0.1

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…irect

Reported on Bdaya-Dev#418 after the redirect fix shipped: login succeeds, but the
browser tab stays open and the user has to close it by hand.

CustomTabsIntent.launchUrl starts the browser with no FLAG_ACTIVITY_NEW_TASK
(browser-1.10.0 sources, CustomTabsIntent.java:828-831 - the only NEW_TASK in
that file is in setAlwaysUseBrowserUI, which launchUrl never calls), and
OidcPlugin passes the host Activity as that Context. So the Custom Tab lives in
the host app's own task:

    host -> CustomTab -> OidcRedirectActivity

OidcRedirectActivity.deliver() then calls finish(), which pops only itself and
leaves the user on the browser. The receiver's own doc comment asserted the
opposite - that finishing it returns the user to the host - which is true only
if the tab were in a separate task, and it is not.

Consuming a redirect now re-launches the host with CLEAR_TOP, which pops the
Custom Tab off the task with it, plus SINGLE_TOP so the existing host receives
onNewIntent rather than being recreated and losing its Flutter engine. Only the
intent-filter path needs it; Auth Tab closes its own tab when it returns the
Activity Result.

The test fails on the assertion that anything is started at all before the fix,
and drains the browser Intent that starting the flow queues so it reads the
redirect's re-launch rather than the launch.

Scope: this pins the Intent's flags. It does not prove the tab disappears on a
device - the fallback path still has no end-to-end coverage, which is why the
defect reached a user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
Eight options on OidcNativeOptionsAndroid were public, documented API that
never reached the browser. OidcPlugin read exactly two keys out of the Pigeon
options map - ephemeralBrowsing and flowTimeoutSeconds - and launchCustomTab
set a single property on the Intent. Everything else was serialized, sent, and
dropped on arrival.

How it passed the suite: platform_options_serialization_test.dart asserts each
option's JSON shape and round-trip. A shape assertion is green whether or not
anything reads the value, so thirteen options round-tripped perfectly and eight
did nothing. The same shape appears in Bdaya-Dev#418 (the Android E2E asserted the login
call was made, never its result) and in the stranded Custom Tab (asserted the
redirect resolved, never that the browser closed).

Now applied: showTitle, urlBarHidingEnabled, shareState, closeButtonPosition,
colorSchemes (including the nested light/dark/default params), partialCustomTabs
(height, resize behaviour, corner radius, background interaction), and
rawIntentExtras. useAuthTab is honoured for the first time: `never` pins the
Custom Tabs + intent-filter path even on a ComponentActivity host, and `force`
now fails loudly with NO_COMPONENT_ACTIVITY instead of silently downgrading.

Unset options leave the browser default, so a caller who configures nothing gets
the same Intent as before. A non-positive partial height is dropped rather than
propagating the IllegalArgumentException that setInitialActivityHeightPx throws.

Two guards, because one is not enough. native_option_wiring_test.dart (Dart)
fails when an option is declared and never referenced natively, with an explicit
exemption map - a dartdoc word-match was tried first and produced a false
positive on allowInsecureConnections, which discloses honestly without using the
word "Reserved". OidcPluginOptionsTest (Kotlin) asserts the extras actually
present on the launched Intent; reverting the wiring turns 7 of its 10 red, and
the 3 that stay green are the two absence assertions plus ephemeralBrowsing,
which already worked.

BREAKING CHANGE: options that previously had no effect now take effect. An app
passing colorSchemes, shareState, showTitle, urlBarHidingEnabled,
closeButtonPosition, partialCustomTabs or rawIntentExtras will see the browser
honour them for the first time. `useAuthTab: force` on a plain FlutterActivity
now fails with NO_COMPONENT_ACTIVITY rather than silently opening a Custom Tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
A cross-platform sweep pointed the same check at OidcPlugin.swift and reported
navigationMode, successfulPageResponse, methodMismatchResponse and
notFoundResponse as inert. They are implemented in oidc_darwin.dart, with their
own loopback test file - the check was reading the wrong layer.

Android has no Dart-side option handling, so one file is sufficient there, but
the guard never said so. It does now, before someone ports it and inherits the
false positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
The README states the package is tested "against multiple conformance
profiles", and the suite hardcoded one plan id. Only Basic RP has ever run, so
every other claimed profile was asserted in docs and verified nowhere - the
same claim-versus-effect gap that produced the inert Android options, one level
up at the spec surface.

Config RP is the first one added because it tests OpenID Connect Discovery,
which the README lists under implemented specs. Plan ids verified against
openid.net and panva/openid-client-certification-suite, which runs basic,
implicit, hybrid and config; the three logout profiles the README also implies
(RP-Initiated, Front-Channel, Session Management) have no published plan id
beyond oidcc-client-test-session-management and are deliberately not guessed at.

Each plan is its own test case rather than a loop inside the runner, so a
failure names the profile that broke and one profile's outage cannot mask
another's.

This is the failing-test step: whether Config RP passes is unknown, because the
conformance suite needs OIDC_CONFORMANCE_TOKEN and can only run in CI. If it
goes red, that is the point - the gap it finds gets fixed next, test-first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
…rmatting

The Config RP case fails at plan creation with HTTP 400, and Dio's message
carries only the status code. The conformance suite does say which variant
dimension it rejected, in the response body, but the bare rethrow discarded
it, so the failure named neither the plan nor the offending key and the only
way to learn the reason was to re-run CI.

Catch DioException around the plan POST and rethrow with the plan name, the
status, the request path and the server's response body.

The variant is now per-plan (clientRegistration, requestType) rather than
hardcoded to static_client. That was the latent assumption behind the 400:
the dimensions a plan accepts are not uniform, and one plan rejects what
another requires.

Also correct the dartdoc. It listed four plan ids under "Verified", which
conflated "transcribed from a published source" with "confirmed to run
here". Only the basic plan is confirmed; the config plan is now known to
reject the variant this harness sends.

`dart format` on app_test.dart and native_option_wiring_test.dart, which
failed the stable unit_tests job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
The Config RP plan failed at creation with HTTP 400. The reason, once the
harness surfaced the suite's response body:

  TestModule 'oidcc-client-test-discovery-openid-config' requires a value
  for variant 'client_auth_type'

Not a rejected client_registration, as first assumed, but a missing third
variant dimension. The Basic plan resolves client_auth_type to
client_secret_basic on its own, which is why omitting it went unnoticed
there; the config plan's discovery module requires it stated outright and
refuses the whole plan without it, before any module runs.

Pass it through the existing extraVariant hook, and set
client_secret_basic on the Config RP case in both harnesses. Basic RP is
left untouched: it is green, and its default resolves correctly.

Also correct the dartdoc, which recorded the earlier wrong guess that the
plan rejects static_client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
…ired variant

The Config RP variant bug could only be found by a CI round trip against a
live third-party server: prepareTestPlanRequest built a request for any plan
out of whatever dimensions it was handed, and the suite answered HTTP 400
thirty minutes later.

Record per-plan required dimensions and throw ArgumentError naming the
missing one, so the mistake surfaces offline and immediately.

Written test-first: the guard test builds the config plan without
client_auth_type and asserts it throws. Confirmed red against the unfixed
helper, which returned the exact request CI had rejected:

  /api/plan?planName=oidcc-client-config-certification-test-plan
    &variant={"request_type":"plain_http_request",
              "client_registration":"static_client"}

A third case asserts the basic plan still builds without the dimension, so
the guard cannot be over-applied to the one plan that is certified and green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
Config RP passed on macos/ios/android and hung on linux/windows. The
difference was not the library: it was that flowTimeoutSeconds was set for
the first three and left null for the desktop pair.

The run stopped at oidcc-client-test-discovery-issuer-mismatch, whose
redirect never arrives, and with no timeout the loopback listener waited
until GitHub Actions killed the job ten minutes later and reaped a pile of
orphan chrome processes. The behaviour is documented on the option itself:
"otherwise loginAuthorizationCodeFlow() and logout hang indefinitely and
leak the bound loopback socket".

Set 30s for linux and windows, matching the other three.

Written test-first, asserting configuration rather than behaviour on
purpose: a hang produces no assertion, no message and no stack, so the
failure it would otherwise surface as is a dead job. The test also bounds
the timeout at 60s, since a per-module timeout long enough to eat the job
budget stops the remaining modules from ever reporting.

Scope: this fixes linux and windows. Web fails separately and is NOT
addressed here -- it has no flowTimeoutSeconds, using popup navigation and
hiddenIframeTimeout instead, so it needs its own diagnosis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
…owTimeoutSeconds

The popup/newPage await had no upper bound. `hiddenIframeTimeout` covers
only the silent-renew iframe, so every other platform could bound an
abandoned login and web could not.

The sole existing escape was the window-closed poller, and it disarms
itself: when COOP severs the WindowProxy it cannot tell a severed handle
from a closed window, so it cancels rather than risk a false positive
(`canDetectPreparedWindowClosure`). After that nothing can complete the
future. A user who walks away from the login, or a browser that applies
COOP, leaves a pending future for the lifetime of the tab.

Add `OidcPlatformSpecificOptions_Web.flowTimeoutSeconds`, mirroring the
Android, Apple and desktop options. Null default, so existing behaviour is
unchanged and this is not breaking.

Also move the teardown into a `finally`. It previously ran only on success,
so an aborted flow left the auth window open behind the app.

Proven by negative control: with the timeout reverted the new browser test
does not fail, it HANGS, and dies on package:test's own 30s limit --
   TimeoutException after 0:00:30.000000: Test timed out after 30 seconds
With the fix it passes in about a second. That is the failure mode being
removed: a hang reports no assertion, no message and no stack.

Found by running the Config RP conformance plan, which hung on web for the
same reason it hung on linux/windows.

oidc_web_core 55 (chrome), oidc_core 733, example 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
sim and others added 27 commits October 6, 2026 12:39
Under bash -e, `vpid="$(cat missing-file)"` takes cat's exit status and
ended the step (run 37440456274: every test passed, the iOS job failed,
and no diagnostics were uploaded). Dry-run of the step under bash -e
with xcrun stubbed now exits 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
Resolves the tests.yaml conflict between this branch's iOS 2-way
sharding and Bdaya-Dev#469's new "Start/Stop simulator log capture" +
"Upload simulator log" steps: both are kept, and the upload artifact
name is made per-shard (ios-${{ matrix.shard }}-simulator-log) since
actions/upload-artifact requires unique names within one run and this
job now has two parallel shards.

No other files conflicted (Bdaya-Dev#491's conformance-log-since-race fix and
Bdaya-Dev#469's harness/test changes landed in integration_test/ and test/,
none of which this branch had touched).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
…rmance

ci: shard native conformance jobs and trim Apple setup
…er-warmup

Rebuilt on main's 2-shard iOS job. The harness (packages/oidc/example)
is taken from main as-is, so the earlier probes, controls and the
module rerun leave this branch's diff against main. What stays:

* .github/scripts/ios_ci_metrics.py, plus cheap default diagnostics:
  a 3s ps/vm_stat host sampler while the tests run. The app's lines and
  runningboardd's WebContent launch lines are read back from the
  simulator's persisted log afterwards. This replaces main's debug-level
  live log stream, which itself cost 40-60% CPU on a starved host.
  Heavy diagnostics (live streams, screen recording) are opt-in via
  workflow_dispatch.
* IOS_REDUCE_LOAD (default on): Spotlight indexing off, other simulators
  shut down, smallest iPhone preferred.
* ios_runner dispatch input, to measure macos-26 (3 vCPU / 7 GB arm64)
  against macos-26-intel (4 vCPU / 14 GB x64, same Xcode 26.6 and
  iOS 26.4 runtime).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
Booting alongside the Flutter install slowed that install 3-4x on the
3-vCPU arm64 runner. The Flutter setup script took 65s with a
synchronous boot and 178-273s with a concurrent one; cache-pub's
hashFiles took 0.5s vs 27-125s. That is a net loss of 80-240s per iOS
leg after Bdaya-Dev#492. The boot now overlaps only flutter precache and the
Patrol CLI activation, which moves ahead of the wait.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
… caching

unit_tests was one job per sdk channel running every step sequentially
(format -> analyze -> VM -> Chrome -> Firefox -> jose Chrome -> jose
Firefox -> oidc_web_core coverage -> oidc_web Chrome -> Flutter ->
coverage combine), making it the critical path of the whole workflow on
main pushes (~41m stable / ~35m beta on run 37423292012). Split into
unit_core/unit_chrome/unit_firefox/unit_jose_chrome/unit_jose_firefox
(sharded 4 ways)/unit_webcore_coverage/unit_oidc_web_chrome/unit_flutter/
unit_tests_coverage so the independent suites run concurrently on the
same ubuntu-latest pool instead of back-to-back. Coverage from the
split jobs is flattened and re-merged by unit_tests_coverage into the
same "package-coverage" artifact the old job produced, so
upload-coverage needed no changes to its own steps (only its needs/if
to account for the new job graph, tolerating the PR-skipped jobs).

Also fixes a real CI failure mode: subosito/flutter-action's `cache:
true` silently also enables its own internal pub-cache step, whose key
unconditionally runs an unscoped `hashFiles('**/pubspec.lock')` -
observed failing outright ("couldn't finish within 120 seconds") on a
macOS leg, and degrading silently to an empty hash segment on Linux
(visible as dead `flutter-linux-stable-x64-<hash>-` cache entries).
Disable that internal step everywhere it's enabled (flutter_base -
shared by every tests.yaml job via integration_test_base - plus
dart_package.yaml/flutter_package.yaml/deploy_firebase.yaml/
firebase_preview.yaml/pana.yaml) and replace it in flutter_base with an
explicit, narrowly-scoped pub-cache step (packages/**/pubspec.yaml,
not an unscoped repo-root **) covering both ~/.pub-cache and the
Windows pub-cache path. Deleted the two dead-format cache entries to
free headroom under the repo's 10GB cache cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
… suite

Re-applied onto main's harness (after Bdaya-Dev#492), unchanged in behaviour from
3903937. Load reduction did not make the iOS simulator's browser stalls
go away: they stayed intermittent across every runner configuration
measured.

When, and only when, the suite's own log proves it received NO
authorization request for an instance, the harness stops that instance
and runs the module once more on a fresh one. The suite observed nothing
there and judged nothing, so no verdict can be hidden. It never reruns
when any authorization request arrived (every negative module), never
reruns a rerun, and never reruns on an unreadable log. The rerun is
printed to the job log and carried into the module's verdict line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
Run 37464828887 caught a real bug: unit_tests_coverage's "Assert Flutter
package coverage survived" guard failed because combine_coverage,
given the flattened coverage/flat-merged/<pkg>.info files directly,
resolves each file's SF: entries relative to the directory the file is
found in whenever those entries are already package-relative (not
absolute) - which is exactly how `flutter test --coverage` writes them
("SF:lib/facade.dart", no "packages/<pkg>/" prefix), unlike the VM/
format_coverage-generated files, whose SF: entries are absolute and
therefore survived the flattening. Flat, the four Flutter packages'
guard matched 0 records each.

Fix: rebuild packages/<pkg>/coverage/lcov.info from the flattened
artifacts before running combine_coverage, so it sees the same
directory layout the old single job always had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
…help

Measured over 2+ iOS runs each, none of these made the simulator browser
stalls (Bdaya-Dev#469) go away:
* Spotlight off, other simulators shut down, smallest iPhone.
  Logins >30s per run: 1 and 4 without, 0 and 1 with (single job). In
  the sharded layout: 0/0 without, 2/1 with.
* macos-26-intel (4 vCPU / 14 GB): Set up Environment took 9.5-15 min,
  and both shards hit the 60-minute timeout in their tests.
Both are removed. What stays from the experiment is the cheap default
diagnostics, heavy diagnostics as a dispatch input, and the boot
ordering. The flaky stall itself is handled by the harness's
evidence-gated single rerun.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
…tom SF

Independent review on PR Bdaya-Dev#494 (run 37476666335) caught a real bug: the
"Restore package-relative coverage layout" step copied
coverage/flat-merged/<pkg>.info to packages/<pkg>/coverage/lcov.info
but left the original flat files in place, so combine_coverage's
recursive scan ingested both. The flat copies resolve to bogus
"/coverage/<relative-path>" phantom SF: entries once they're no longer
sitting in a per-package directory. That run's package-coverage
artifact had 254 SF: lines for 139 unique files (115 duplicates) and
11 phantom /coverage/... paths, against a clean 132/132 on the
baseline main run (37423292012).

Fix: `mv` instead of `cp`, plus an explicit `rm -rf coverage/flat-merged`
after the loop, so the stale flat files can never be re-scanned.

Also adds a structural guard ("[Coverage] Assert no duplicate or
phantom SF entries") right after the combine step: fails the job if
any SF: path appears more than once, or if any SF: path doesn't
correspond to a real file in the checkout. This catches this failure
mode (and anything similar) directly, instead of relying only on the
package-specific guards, which passed on the buggy run because the
correct entries were also present alongside the duplicates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
…ched

Review of Bdaya-Dev#495 (F1): the gate read a request the suite REFUSED as one
that never arrived. The suite's dispatcher logs "Incoming HTTP request to
<path>" before the module runs (TestDispatcher). The "Authorization
endpoint" block is only written once the module moves to RUNNING. That
throws "Illegal test state change" for a FINISHED/INTERRUPTED instance
(AbstractTestModule), and OIDCCClientTestDiscoveryIssuerMismatch throws
before the block. So a refused request left no block, and the gate
would have rerun past a real verdict.

* The gate now requires the instance to still be WAITING, read from
  GET api/info/{id} before anything is discarded. That is the stall's
  signature. An instance with a verdict is never rerun, whatever its
  log says. The log is only read for a WAITING instance.
* An "Incoming HTTP request to …/authorize" line counts as arrived,
  as well as the Authorization endpoint block.
* The RERUN note names the discarded instance's status and result.
* Unit tests cover the refused-request log shape, FINISHED/FAILED,
  FINISHED/PASSED and INTERRUPTED/FAILED first attempts, an unknown
  status, query strings, and other endpoints. Mutation-checked:
  dropping the WAITING condition or the arrival-line match each fails a
  test.
* The doc comments (api.dart, tests.yaml) now state the real
  conditions, and say the gate applies on every platform (A1).

Also from the review:
* F2: the job comment no longer says the boot overlaps Set up
  Environment.
* A2: CONFORMANCE_FORCE_STALL_MODULE (workflow_dispatch input
  ios_force_stall_module, empty by default) makes the first attempt of
  one module never open its browser, once per process, to prove the
  rerun path end to end in CI.
* A3: ios_ci_metrics.py closes a login on the RERUN line too, so the
  discarded attempt's stall still counts.
* A4: the collect step tolerates a missing pids file. A dry run under
  bash -eo pipefail with xcrun stubbed and no pids file exits 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
ci: run unit test suites as parallel jobs and fix pub caching
…er-warmup

No conflicts. upload-coverage's needs: list (main's split unit_* jobs
plus android/ios/web/macos/linux/windows) is unchanged. Its
"*-integration-coverage" download pattern still picks up the iOS legs'
ios-<shard>-integration-coverage artifacts and not the new
ios-<shard>-diagnostics ones. actionlint is clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
Run 37486189186's unit_core (beta) job hung 85+ minutes in "Test Dart
packages [VM]" with no bound at all (GitHub's default job timeout is
360 minutes) until a human cancelled it. Every package had already
printed its own "N tests passed" summary (oidc_core's, the last one,
at the 4-minute mark); x509_plus had hit "Connection reset by peer"
loading one test file via its VM-service websocket mid-run (73 passed,
1 failed) shortly before. melos's exec wrapper never returned
afterward, and job cancellation found an orphaned dart:test.dart-
process at cleanup.

- Adds job-level timeout-minutes to every unit_* job and
  unit_tests_coverage that didn't already have one (unit_core,
  unit_chrome, unit_firefox, unit_oidc_web_chrome, unit_flutter,
  unit_tests_coverage), sized from measured durations across several
  runs with a 2-5x margin. unit_jose_chrome/unit_jose_firefox/
  unit_webcore_coverage already had sensible job-level bounds (60/60/
  20m) and are unchanged.
- Wraps unit_core's VM step in `timeout --foreground -k 10 600`
  (matching the pattern unit_chrome/unit_firefox's PR-scope retry
  loops already use), the actual fix for the hang: GNU timeout signals
  the whole process GROUP (TERM then KILL), which is what's needed to
  reap an orphaned child process a hung `melos run test:vm` left
  behind, rather than relying on GitHub's own job-level kill as the
  only way out.

Investigated whether the CI job split (PR Bdaya-Dev#494) changed melos exec's
concurrency for this step: it didn't -- melos defaults to
Platform.numberOfProcessors regardless, and that's unchanged by the
split (same runner, same package set, same formula, before and
after). Checked several historical main-push runs (pre- and
post-split): the "Connection reset by peer" / hang signature did not
reproduce in any of them, including the very next main push after the
split landed. This looks like a rare, intermittent VM-service
coverage-collection race, not something the split caused.

As defense-in-depth (not a proven root-cause fix, per the above),
pubspec.yaml's test:vm script now caps melos exec concurrency at 2 for
this step specifically (fewer concurrent VM-service websockets ==
lower odds of hitting whatever triggers the reset), using melos's
map-form `exec: {concurrency, command}` schema (confirmed supported by
the melos 8.9.0 this repo's CI actually activates).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
ci(ios): rerun a module once when the browser never reached the suite; boot after setup
The first validation run (37502004205) failed both unit_core legs
immediately with:

  MissingScriptCommandException: The script test:vm failed to execute.
  You must specify a script to run...

from melos 8.9.0 (the version this repo's CI actually activates),
despite the previous commit's `exec: {concurrency, command}` map form
matching melos's own source for that exact tag. Not trusted further.

Replaced with the same pattern the existing `pana` script already uses
successfully in this file (`run: melos exec -c 1 "pana"`): a `run:`
script that invokes `melos exec -c 2` directly, with --no-flutter
--dir-exists=test --ignore="*web*" replicating the dropped
packageFilters exactly as melos exec's own CLI filter flags (confirmed
from melos's actual command_runner/exec.dart + base.dart source for
the 8.9.0 tag).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
chore(release): Publish packages
Main run 37502027906 (linux implicit, attempt 1) failed Implicit RP on
oidcc-client-test. The authorization request reached the suite 30.07s
after the login started, the loopback listener gave up at 30s, and the
module was left WAITING. The gated rerun correctly stayed out of it,
because the request did arrive. The same module's next attempt took
1.9s.

It is the plan's FIRST login that is slow: it pays for the CI runner's
system browser cold-starting. Login durations across 6 runs' legs
("Starting login" -> "[e2e] ... authResult"), first login of each plan
vs every other login:
  linux    first n=46  max 30.4s (then 24.6, 12.7, 12.3, 12.1)  others max 7.7s (n=884)
  windows  first n=45  max 15.2s (then 13.3, 10.2, 8.7)         others max 7.9s (n=885)
  macos    first n=20  max 14.0s                                others max 2.4s (n=445)
Android logs carry no Dart output, so there is no data there; it stays
at 30s, and so does macOS.

linux/windows now get desktopConformanceFlowTimeoutSeconds = 60, about
twice the worst first login seen. A pinning test ties it to
observedDesktopFirstLoginSeconds (30.4s), with a 1.5x margin; setting it
back to 30 fails the test. It costs nothing on a healthy flow, which
ends at the redirect, and stays inside the existing <= 60s per-module
budget check.

No browser warm-up: the cold start happens once per plan either way.
The timeout only has to tolerate it, and adding a warm-up would cost a
browser launch per plan on every platform.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
--foreground stops GNU timeout from timing out COMMAND's children, so
it could not reach an orphaned `dart test`. Drop it so the TERM/KILL
goes to melos's process group, and correct the comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
ci: bound unit test jobs and the VM test step so a hung test can't stall a run
…eout

test(example): give a desktop plan's first login time for a cold browser
subosito/flutter-action's `cache: true` wraps the Flutter SDK download in
its own internal actions/cache step, keyed on outputs
(CACHE-PATH/CACHE-KEY) produced by an earlier step in the same composite
action. That internal cache step's POST (save) phase is a long-standing,
still-open upstream bug on macOS and Windows runners
(subosito/flutter-action#350): it cannot re-resolve the
`${{ steps.flutter-action.outputs.CACHE-PATH }}` expression its own
`path` input depends on, logs "Input required and not supplied: path",
and silently no-ops -- so the SDK is never persisted and every run on
those OSes re-downloads+extracts it from scratch (confirmed live on this
repo's own runs, e.g. run 37523659563: the windows/macos/ios "Cache
Flutter" POST step prints exactly that warning). Linux jobs going
through the same flutter_base action look fine only because they already
have a warm cache from before and never reach the broken save path.

Fix: resolve the (channel/version/arch/hash)-based cache coordinates via
a `dry-run: true` flutter-action invocation (its "Set action inputs" step
always runs in print-only mode regardless and exposes CACHE-PATH/
CACHE-KEY as composite outputs), cache that path ourselves with a plain,
non-nested actions/cache step (same pattern as the pub-cache step just
below it, whose POST already resolves correctly cross-OS), then run
flutter-action again with `cache: false` to actually install -- it skips
the download when our step already restored the SDK into that path.
Applied to flutter_base (used by every tests.yaml job) and every other
workflow invoking flutter-action with `cache: true`
(dart_package/flutter_package/pana/firebase_preview/deploy_firebase.yaml).

Also drops firebase_preview.yaml/deploy_firebase.yaml's custom
`cache-key` that omitted `:version:`: it produced a cache entry
(`flutter-linux-stable-x64-<hash>`) duplicating the bytes of the one
flutter_base already populates for the same channel/version/arch/hash.
Using flutter-action's own default key (via the dry-run step) unifies
with that entry instead of duplicating it, which matters under this
repo's 10GB total cache cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
Live verification (run 37532075225, dispatched on this branch) showed
the first fix attempt wasn't enough: routing the SDK cache through our
own, non-nested actions/cache step still hit the exact same "Input
required and not supplied: path" warning on macOS/Windows, because its
`with: path/key` referenced `${{ steps.flutter_sdk_info.outputs.* }}`
directly. The bug isn't specific to flutter-action's own internal cache
step or to composite-action nesting -- it's that a `with:` expression
reading `steps.<id>.outputs.*` cannot be re-resolved during that
action's own POST (save) phase on macOS/Windows runners, regardless of
whose step it is. The existing pub-cache step never hit this because its
key/path only reference flat, non-output values (inputs/runner.os/
hashFiles), which DO replay correctly in POST.

Fix: copy the two outputs into plain env vars via $GITHUB_ENV right
after resolving them, and have the cache step reference `env.*` instead
of `steps.*.outputs.*`. Applied to flutter_base and every other workflow
carrying the same pattern from the previous commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
…saves

Two cache-budget fixes requested on top of the Flutter SDK cache fix:

1. android job (tests.yaml): gradle/actions/setup-gradle's own
   cache-read-only default (read-only off the default branch, read-write
   on it) doesn't account for this job being sharded 4 ways. On every
   main push all 4 shards run concurrently and all get write access,
   independently racing to save the same (not shard-scoped)
   gradle-dependencies-v2/gradle-transforms-v2 entries -- confirmed live
   via the cache listing: two non-identical sibling entries each
   (1.13GB+0.67GB, 1.31GB+0.47GB) instead of one shared entry. Layered a
   single-writer rule on top (`matrix.shard != 'basic'` forces
   cache-read-only even on main) while leaving the upstream per-branch
   default untouched for every other case. Deleted the two confirmed
   duplicate entries to free the space immediately.

2. Flutter SDK cache (flutter_base + the other flutter-action callers):
   split the combined actions/cache step into actions/cache/restore
   (always, every ref) and actions/cache/save (only
   `github.event_name == 'push' && github.ref == 'refs/heads/main'`).
   PR and workflow_dispatch runs now restore whatever main has already
   populated but never write their own branch-scoped copy of this
   multi-GB-per-OS entry. As a side benefit, the split actions have no
   POST hook at all, so this no longer depends on the env-var replay fix
   working correctly in a deferred phase -- the save simply runs, or
   doesn't, synchronously at this point in the job.

Steady-state cache estimate (current measured total via
`gh api repos/Bdaya-Dev/oidc/actions/cache/usage`, after the dedup
above, plus the macOS/Windows SDK entries this fix adds once a real
main push populates them): ~6.40GB existing + 2.22GB (macOS SDK) +
1.93GB (Windows SDK) =~ 10.26GB -- still slightly over the 10GB cap by
~2.5%, driven almost entirely by the two new, unavoidable multi-GB SDK
entries this fix exists to create. GitHub's eviction policy degrades
gracefully (LRU eviction, not a failure) if exceeded; flagged for the
coordinator rather than silently claimed as safely under budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
actions/cache/save has no memory of a separate restore step, so every
main push re-uploaded the ~2 GB SDK, once per shard. Give each restore
step an id and gate the save on cache-hit != 'true'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ci: make the Flutter SDK cache actually save on macOS/Windows, and dedupe Gradle
@pedrosimoespt
pedrosimoespt self-requested a review November 3, 2026 14:58
@henrique-marques-present
henrique-marques-present merged commit c36f1dd into main Nov 3, 2026
24 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants